fix: stop duplicated assistant text and stuck tool spinners - #224
Conversation
… Code chat Claude Code's stream-json protocol assigns mismatched ids across events for the same logical block: text content blocks never carry an "id" so the final full-message replay got a random UUID instead of the deterministic id streamed deltas already used, producing a duplicate text part. Similarly, tool_result lines report their own (often absent) message id rather than the id of the message that held the running tool_use, so results landed on a stranded new message and the original tool card never left the "running" state. Give text/thinking blocks the same deterministic part id as their delta stream, and route tool_result parts back onto the tool_use's originating message (merging into it instead of replacing it, since that store is also what gets persisted to disk). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
🔍 OpenCodeReview found 3 issue(s) in this PR.
|
| val originMessageId = | ||
| blocks.firstNotNullOfOrNull { block -> | ||
| if (block.string("type") == "tool_result") openTools[block.string("tool_use_id")]?.messageId else null | ||
| } |
There was a problem hiding this comment.
[bug · medium]
originMessageId の決定に blocks.firstNotNullOfOrNull を使い、最初にマッチした tool_result の属先メッセージに newParts 全体を紐付けています。1つの user メッセージに複数の assistant メッセージ由来の tool_result が含まれる場合、または先頭の tool_result の tool_use_id が既に openTools から削除済み(先に settle 済みなど)で次の tool_result にフォールバックする場合、後続の tool_result が誤ったメッセージにマージされ、本来更新されるべき tool_use カードが未更新のまま残る可能性があります。ブロックごとに属先メッセージを解決し、メッセージ単位でまとめてマージする方が安全です。
| if (block.string("type") == "tool_result") openTools[block.string("tool_use_id")]?.messageId else null | ||
| } | ||
| val messageId = originMessageId ?: message.string("id") ?: newMessageId() | ||
| currentMessageId = messageId |
There was a problem hiding this comment.
[bug · low]
tool_result を元の assistant メッセージにルーティングした結果、currentMessageId が originMessageId(過去の実在メッセージ)に設定されます。parsePartialDelta は currentMessageId を messageId として使うため、直後の assistant ターンのテキストデルタ(content_block_delta)が元メッセージのパーツID "$originMessageId-text" に紐付けられ、新しい応答のテキストが前のメッセージに誤って反映される恐れがあります。ルーティング後は currentMessageId を更新しない(または次ターン用にリセットする)など、tool_result 受信時に currentMessageId を汚さない設計にしてください。
| OpenCodePart(partId, sessionId, messageId, "text", text = block.string("text").orEmpty()) | ||
| // Must match parsePartialDelta's id for the same field so the final full-message | ||
| // replay overwrites the streamed-in text instead of appearing as a duplicate block. | ||
| OpenCodePart("$messageId-text", sessionId, messageId, "text", text = block.string("text").orEmpty()) |
There was a problem hiding this comment.
[bug · medium]
text ブロックのパーツIDが "$messageId-text" に固定されており、thinking も "$messageId-reasoning" 固定です。1つのメッセージに text ブロックが複数含まれる場合(例: text→tool_use→text、Claude Code の content 配列は複数ブロックを許可)、すべて同じパーツIDになります。この結果、既存メッセージとのマージ時(byId[it.id] = it の後勝ち上書き)に先頭の text ブロックが失われるほか、events にも同一IDのパーツが複数流れ、UI側のパーツ更新が競合します。parsePartialDelta の "$messageId-$field" と一致させる必要があるのは理解できますが、複数ブロックを安全に扱うなら、ブロックごとの一意情報(content_block の index など)をIDに含めることを検討してください。
Summary
idin Claude Code's stream-json protocol, so the final full-message replay for a block got a random UUID instead of the deterministic id already used by the streamed deltas, producing a duplicate part.tool_resultlines report their own (often absent) message id rather than the id of the message that held the runningtool_use, so the result landed on a stranded new message and the original card was never updated.Fix
ClaudeStreamJsonParser.parseContentBlock: give text/thinking blocks the same deterministic part id ("$messageId-text"/"$messageId-reasoning") thatparsePartialDeltaalready uses for the streamed deltas.ClaudeStreamJsonParser.parseModelMessage: track the originating message id per open tool call (openTools) and route atool_result's parts back onto that message, merging into its existing parts instead of replacing the message wholesale (important since this state is also what's persisted to disk viaClaudeMessageStore.upsert).Test plan
ClaudeStreamJsonParserTest: one asserting a replayed full text block merges onto the streamed part instead of duplicating it, one asserting atool_resultwith an unrelated message id lands back on the originatingtool_usemessage.ClaudeStreamJsonParserTestsuite (12 tests) passes, verified via a standalonekotlinc+ JUnit run (the sandboxed build environment here can't run the full Gradle Android build —aapt2is x86-64-only in this ARM/proot host — so this was run outside Gradle).🤖 Generated with Claude Code